Using never type for exhaustive checks
Chat GPT 5 Answer
이 글에서는
-
never
타입이 뭔지, -
switch
에서 어떻게 모든 경우의 수를 처리하도록 강제하는지, -
현업(예: NestJS, DDD/이벤트소싱)에서 바로 쓸 수 있는 변형/확장 패턴,
-
ts-pattern
같은 패턴매칭 라이브러리로의 확장
까지 빠르게 정리합니다.
1) never
타입 한 줄 정의
-
정의: “절대 값이 존재할 수 없는 타입”. 함수가 항상 예외를 던지거나, 무한 루프로 끝나거나, 도달 불가능한 분기에 대응할 때 등장합니다.
-
핵심 속성:
-
never
는 모든 타입의 서브타입이지만, 어떤 타입도never
의 서브타입이 아닙니다. (즉, 대입은 불가능) -
const x: never = something
이 성공하려면something
이 컴파일러 관점에서 절대 발생할 수 없어야 합니다.
-
이 성질을 이용하면 “이 분기(default)에 들어오는 값은 논리적으로 불가능해야 한다”를 컴파일러에게 증명하게 만드는 게 가능합니다.
2) Exhaustive switch guard – 스니펫 해설
switch (e.do) {
case 'CONFIRM_RESERVATION':
this.logger.debug(inspect(e));
break;
case 'SCHEDULE_REMINDER':
this.logger.debug(inspect(e));
break;
case 'EMIT_OUTBOX':
this.logger.debug(inspect(e));
await this.message.sendNotification(e.payload);
break;
case 'RELEASE_PROVISIONAL_RESERVATION':
this.logger.debug(inspect(e));
break;
case 'CANCEL_REMINDER':
this.logger.debug(inspect(e));
break;
default:
const exhaustiveSwitch: never = e;
console.log(exhaustiveSwitch);
throw new RacketimeException(
'PAYMENT_SESSION',
'NOT_ALL_EFFECTS_HANDLED',
e,
);
}
어떻게 “빠뜨린 case”를 막을까?
-
전제:
e.do
가 문자열 리터럴 유니온이어야 합니다. 예:type EffectDo = | 'CONFIRM_RESERVATION' | 'SCHEDULE_REMINDER' | 'EMIT_OUTBOX' | 'RELEASE_PROVISIONAL_RESERVATION' | 'CANCEL_REMINDER'; type Effect = | { do: 'CONFIRM_RESERVATION'; payload: /* ... */ } | { do: 'SCHEDULE_REMINDER'; payload: /* ... */ } | { do: 'EMIT_OUTBOX'; payload: /* ... */ } | { do: 'RELEASE_PROVISIONAL_RESERVATION'; payload: /* ... */ } | { do: 'CANCEL_REMINDER'; payload: /* ... */ };
-
default
분기에서const exhaustiveSwitch: never = e;
를 시도하면,-
모든 case를 처리한 상황이라면
e
의 잔여 타입은never
가 되어 대입 성공합니다. -
하나라도 빼먹으면
e
의 잔여 타입이never
가 아니므로 컴파일 에러가 납니다.
-
즉, 새로운 효과 타입을 추가했는데 switch
에 반영하지 않으면 빌드가 깨지게 됩니다. 운영 중 “핸들 안 된 효과”가 조용히 유실되는걸 사전에 차단합니다.
3) 실무 친화적 변형: assertNever()
헬퍼
default
에 로깅/예외를 포함하는 대신, 재사용 가능한 헬퍼로 분리하면 더 깔끔합니다.
function assertNever(x: never, message?: string): never {
// 런타임 안전망: 혹시 컴파일러가 놓치거나 any가 새어들면 알람
throw new Error(message ?? `Unexpected object: ${JSON.stringify(x)}`);
}
// 사용 예
switch (e.do) {
case 'CONFIRM_RESERVATION': /* ... */ break;
case 'SCHEDULE_REMINDER': /* ... */ break;
case 'EMIT_OUTBOX': /* ... */ break;
case 'RELEASE_PROVISIONAL_RESERVATION': /* ... */ break;
case 'CANCEL_REMINDER': /* ... */ break;
default:
assertNever(e, 'NOT_ALL_EFFECTS_HANDLED');
}
NestJS/DDD 맥락에서의 팁
-
도메인 이벤트/이펙트 스키마를
as const
로 고정해 리터럴 유니온을 보존하세요. -
외부에서 들어오는 값(예: MQ, HTTP)을 **파싱 계층(Zod/class-validator)**에서 좁힌 뒤 애그리게이트/유스케이스로 전달하세요.
-
ESLint 규칙
@typescript-eslint/switch-exhaustiveness-check
를 켜두면 협업 시 누락을 더 빨리 잡습니다.
4) 유니온 축소(discriminated union)로 안전하게 분기
e.do
를 태그 필드로 쓰는 **식별 합집합(Discriminated Union)**은 switch와 궁합이 탁월합니다.
type ConfirmReservation = { do: 'CONFIRM_RESERVATION'; payload: { /* ... */ } };
type EmitOutbox = { do: 'EMIT_OUTBOX'; payload: { to: string; text: string } };
type Effect = ConfirmReservation | EmitOutbox /* | ... */;
function handleEffect(e: Effect) {
switch (e.do) {
case 'CONFIRM_RESERVATION':
// e.payload는 ConfirmReservation의 payload로 좁혀짐
return;
case 'EMIT_OUTBOX':
// e.payload.to, e.payload.text 안전 접근
return;
default:
assertNever(e);
}
}
장점:
-
케이스별 payload 타입이 자동으로 좁혀져서
e.payload.to
같은 접근이 안전합니다. -
실행 시점에 타입 가드 코드를 덜 작성해도 됩니다.
5) 문자열 리터럴 보존: as const
와 satisfies
문자열이 단순 string
으로 확장되면 exhaustiveness가 깨집니다. 데이터 정의 시 리터럴을 고정하세요.
// ✅ 리터럴 고정
const EFFECTS = [
'CONFIRM_RESERVATION',
'SCHEDULE_REMINDER',
'EMIT_OUTBOX',
'RELEASE_PROVISIONAL_RESERVATION',
'CANCEL_REMINDER',
] as const;
type EffectDo = (typeof EFFECTS)[number];
또는 satisfies
로 구조는 느슨하게, 키는 정확하게:
const handlers = {
CONFIRM_RESERVATION: (e: ConfirmReservation) => { /* ... */ },
EMIT_OUTBOX: (e: EmitOutbox) => { /* ... */ },
// ...
} satisfies Record<EffectDo, (e: Effect) => Promise<void> | void>;
satisfies
는 타입 체크만 하고 값은 변환하지 않으므로 런타임 오버헤드가 없습니다.
6) switch
대신 “테이블 매핑 + assertNever”도 가능
로직이 함수형으로 더 어울릴 때는 매핑 테이블을 써도 됩니다.
type Handler<T extends Effect> = (e: T) => Promise<void> | void;
const handlerMap: Record<EffectDo, Handler<any>> = {
CONFIRM_RESERVATION: (e: ConfirmReservation) => { /* ... */ },
SCHEDULE_REMINDER: (e) => { /* ... */ },
EMIT_OUTBOX: (e: EmitOutbox) => this.message.sendNotification(e.payload),
RELEASE_PROVISIONAL_RESERVATION: (e) => { /* ... */ },
CANCEL_REMINDER: (e) => { /* ... */ },
};
export function handleEffect(e: Effect) {
const handler = handlerMap[e.do];
if (!handler) assertNever(e); // 누락 시 컴파일/런타임 모두 잡음
return handler(e as never);
}
장점:
-
케이스 추가/삭제가 diff에 깔끔하게 드러납니다.
-
테스트에서 각 케이스를 함수 단위로 모킹하기 쉽습니다.
7) enum
에서도 동일하게 적용
리터럴 유니온 대신 enum
을 쓴다면:
export enum EffectDoEnum {
CONFIRM_RESERVATION,
SCHEDULE_REMINDER,
EMIT_OUTBOX,
RELEASE_PROVISIONAL_RESERVATION,
CANCEL_REMINDER,
}
type Effect =
| { do: EffectDoEnum.CONFIRM_RESERVATION; /* ... */ }
| { do: EffectDoEnum.SCHEDULE_REMINDER; /* ... */ }
// ...
switch (e.do) {
case EffectDoEnum.CONFIRM_RESERVATION: /* ... */ break;
// ...
default:
assertNever(e);
}
숫자 enum은 연산에 쓰이기 쉬운 반면, 문자열 리터럴 유니온은 직관적 디버깅/직렬화에 유리합니다. 이벤트/메시지 타입은 보통 문자열 리터럴을 추천합니다.
8) 패턴 매칭 스타일 (ts-pattern
)로 더 간결하게
복잡한 가드/중첩 분기를 깔끔하게 쓰고 싶다면 ts-pattern
을 고려하세요.
import { match, P } from 'ts-pattern';
match(e)
.with({ do: 'EMIT_OUTBOX' }, ({ payload }) => this.message.sendNotification(payload))
.with({ do: 'CONFIRM_RESERVATION' }, () => { /* ... */ })
.with({ do: 'SCHEDULE_REMINDER' }, () => { /* ... */ })
.with({ do: 'RELEASE_PROVISIONAL_RESERVATION' }, () => { /* ... */ })
.with({ do: 'CANCEL_REMINDER' }, () => { /* ... */ })
.exhaustive(); // ❗ 누락 시 컴파일 에러
장점:
-
객체 패턴, 가드(
when
), 일부 필드만 매칭 등 표현력이 높음. -
exhaustive()
가 스위치 가드와 같은 역할을 수행.
9) 실무에서 흔한 함정과 체크리스트
-
[함정]
e.do
가 실수로string
으로 넓혀짐(widening) →default
가 항상 reachable → 가드 무용지물
대응: 입력 파싱 계층에서 스키마로 리터럴 좁히기 +as const
/satisfies
. -
[함정]
any
가 새어 들어오면 타입 체커가 무력화
대응:noImplicitAny
/strict
켜기, 외부 IO는 Zod/class-validator로 정제. -
[체크리스트]
-
strict
모드 -
리터럴 유니온 유지(
as const
) -
assertNever()
도입 -
ESLint switch exhaustiveness 규칙 활성화
-
이벤트 추가 시 CI에서 타입 에러를 실패로 처리
-
10) 도메인 이벤트/이펙트에 바로 적용하기 (NestJS 예시)
@Injectable()
export class EffectHandler {
constructor(private readonly message: MessageService, private readonly logger: Logger) {}
async handle(e: Effect) {
switch (e.do) {
case 'CONFIRM_RESERVATION':
case 'SCHEDULE_REMINDER':
case 'RELEASE_PROVISIONAL_RESERVATION':
case 'CANCEL_REMINDER':
this.logger.debug(inspect(e));
return;
case 'EMIT_OUTBOX':
this.logger.debug(inspect(e));
await this.message.sendNotification(e.payload);
return;
default:
// 컴파일 타임: 누락 케이스가 있으면 에러
// 런타임: any/IO 누수 대비 이중 안전망
assertNever(e, 'NOT_ALL_EFFECTS_HANDLED');
}
}
}
마무리
-
never
는 “여기 들어오면 안 된다”를 타입 체커에게 증명시키는 안전장치입니다. -
exhaustive switch guard는 도메인 규칙의 폐쇄성(추가/변경 시 반드시 전파)을 컴파일 타임에 보장하는 가장 단단한 패턴입니다.
-
팀 규모가 커지고 이벤트/이펙트가 늘어날수록 테이블 매핑 +
assertNever
+ ESLint 혹은 **ts-pattern
**로 “케이스 누락”을 막는 설계가 유지보수 비용을 크게 줄입니다.